package de.fhkl.strokeserver.core;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Server waits for incoming connections and handles them by starting a new
 * connection handler thread for them. The connection handlers will then do all
 * the work.
 */
public class StrokeServer
{
	/** Init logger for this class */
	private static Logger sLogger = LoggerFactory.getLogger(StrokeServer.class);

	/**
	 * Singleton pattern! sInstance holds the only instance of Server.
	 * 
	 * @see getInstance()
	 */
	private static volatile StrokeServer sInstance;

	/** Lock object */
	private static Object sLock = new Object();

	/** Port on which the server should listen */
	private static int sPort;

	/** Path to the folder where incoming videos will be stored */
	private static String sVideoPath = System.getProperty("video_path");

	/** Allows to stop the server thread */
	private static boolean sRun;

	/** Server socket */
	private ServerSocket mServerSocket;

	/** Server thread */
	private Thread mServerThread;

	/**
	 * Task that periodically removes old activity logs from the list of recent
	 * activities.
	 */
	private CleanUpTask mCleanUpTask;

	/**
	 * Task that periodically checks all open connections in order to remove
	 * connections that timed out.
	 **/
	private ConnectionWatchDog mConnectionWatchDog;

	/** List of all open connections */
	private List<ConnectionHandler> mConnections;

	/** Open connections to doctors */
	private Map<UUID, ConnectionHandler> mDoctors;

	/** Open connections to paramedics */
	private Map<UUID, ConnectionHandler> mParamedics;

	/** List of recent activities that have not yet been completed */
	private List<ActivityLog> mActivityLogs;

	/** Received video parts */
	private Map<UUID, List<byte[]>> mVideoPartsMap;

	/**
	 * Server waits for incoming connections and handles them by starting a new
	 * connection handler thread for them. The connection handlers will then do
	 * all the work.
	 * 
	 * @param port
	 */
	private StrokeServer()
	{
		mConnections = new LinkedList<ConnectionHandler>();

		mDoctors = new HashMap<UUID, ConnectionHandler>();
		mParamedics = new HashMap<UUID, ConnectionHandler>();

		mActivityLogs = new LinkedList<ActivityLog>();

		mVideoPartsMap = new HashMap<UUID, List<byte[]>>();
	}

	/**
	 * Singleton pattern!
	 * 
	 * @return the only instance of StrokeServer.
	 */
	public static StrokeServer getInstance()
	{
		if (sInstance == null)
		{
			synchronized (sLock)
			{
				if (sInstance == null)
				{
					sInstance = new StrokeServer();
				}
			}
		}

		return sInstance;
	}

	/**
	 * Loads server configs when class is loaded.
	 */
	static
	{
		try
		{
			loadConfigs();
		}
		catch (Exception e)
		{
			sLogger.error("Couldn't load server configs." + e.getMessage());
		}
	}

	/**
	 * Loads server configs from properties file.
	 * 
	 * @throws IOException
	 */
	private static void loadConfigs() throws IOException
	{
		Properties properties = new Properties();
		properties.load(StrokeServer.class.getClassLoader().getResourceAsStream("config.properties"));

		sVideoPath = properties.getProperty("video_path", "");
		sPort = Integer.parseInt(properties.getProperty("port", "50001"));
	}

	/**
	 * Get port.
	 * 
	 * @return
	 */
	public static int getPort()
	{
		return sPort;
	}

	/**
	 * Allows to set a port to run on.
	 * 
	 * @param port
	 */
	public static void setPort(int port)
	{
		sPort = port;
	}

	/**
	 * Location where the server should save incoming video files.
	 * 
	 * @return
	 */
	public static String getVideoPath()
	{
		return sVideoPath;
	}

	/**
	 * Checks whether the server is already running.
	 * 
	 * @return
	 */
	public static boolean isRunning()
	{
		return sRun;
	}

	/**
	 * Starts the server in a new thread that listens for any incoming
	 * connections.
	 */
	public boolean start()
	{
		sLogger.info("Starting server...");
		sRun = true;

		try
		{
			loadConfigs();
		}
		catch (IOException e)
		{
			sLogger.error("Couldn't load server configs. Start canceled." + e.getMessage());
			return false;
		}

		Runnable runnable = new Runnable()
		{
			@Override
			public void run()
			{
				// open server socket
				try
				{
					mServerSocket = new ServerSocket(sPort);
				}
				catch (IOException e)
				{
					sLogger.error("Couldn't create server socket!" + e.getMessage());
					return;
				}

				sLogger.info("Server successfully started.");

				// ... and wait for incoming connections
				while (sRun)
				{

					try
					{
						Socket socket = mServerSocket.accept();

						sLogger.info("New incoming connection(" + socket.getInetAddress() + ":" + socket.getPort()
								+ "). Starting connection handler.");

						// create a connection handler for this connection
						ConnectionHandler connectionHandler = new ConnectionHandler(socket);

						// add connection handler to the list of open
						// connections
						mConnections.add(connectionHandler);
					}
					catch (SocketException e)
					{
						if (sRun)
						{
							sLogger.error(e.getMessage());
						}
						else
						{
							break;
						}
					}
					catch (IOException e)
					{
						sLogger.error(e.getMessage());
					}
				}
			}
		};

		// start the server thread
		mServerThread = new Thread(runnable);
		mServerThread.start();

		// start clean up task, repeats every 5 minutes
		mCleanUpTask = new CleanUpTask();
		mCleanUpTask.start(5 * 60 * 1000, true);

		// start connection watch dog, repeats every minute
		mConnectionWatchDog = new ConnectionWatchDog();
		mConnectionWatchDog.start(60 * 1000, true);

		return true;
	}

	/**
	 * Closes all open connections and stops the server thread.
	 */
	public void stop()
	{
		sLogger.info("Stopping server...");

		try
		{
			// stop clean up task
			mCleanUpTask.cancel();

			// stop connection watchdog
			mConnectionWatchDog.cancel();

			// close all open connections
			closeConnections();

			// close server socket and stop server thread
			sRun = false;

			mServerSocket.close();
			mServerSocket = null;

			mServerThread.join();
			mServerThread = null;

			sInstance = null;

			sLogger.info("Server successfully stopped.");
		}
		catch (Exception e)
		{
			sLogger.error(e.getMessage());
		}
	}

	/**
	 * Removes the specified connection from the list of open connections.
	 * 
	 * @param connectionHandler
	 */
	public void removeConnection(ConnectionHandler connectionHandler)
	{
		mConnections.remove(connectionHandler);

		UUID userId = connectionHandler.getSenderId();
		mParamedics.remove(userId);
		mDoctors.remove(userId);
	}

	/**
	 * Closes the given connection and removes it from the list of open
	 * connections.
	 * 
	 * @param connectionHandler
	 */
	public void closeConnection(ConnectionHandler connectionHandler)
	{
		sLogger.info("Remove connection from list of open connections.");

		connectionHandler.close();

		removeConnection(connectionHandler);

		sLogger.info("ConnectionHandler successfully stopped.");
	}

	/**
	 * Closes all open connections.
	 */
	private void closeConnections()
	{
		sLogger.info("Closing all connections");

		List<ConnectionHandler> connections = new ArrayList<ConnectionHandler>(mConnections);
		for (ConnectionHandler connection : connections)
		{
			closeConnection(connection);
		}
	}

	/**
	 * Returns the connection that has been opened by the user with the given
	 * ID.
	 * 
	 * @param userId
	 * @return
	 */
	public ConnectionHandler getConnection(UUID userId)
	{
		if (mParamedics.containsKey(userId))
		{
			return mParamedics.get(userId);
		}

		return mDoctors.get(userId);
	}

	/**
	 * Returns a list of all doctors currently logged in.
	 * 
	 * @return
	 */
	public List<ConnectionHandler> getDoctors()
	{
		return new ArrayList<ConnectionHandler>(mDoctors.values());
	}

	/**
	 * Returns the connection of the doctor with the given ID or null it that
	 * doctor is not currently logged in.
	 * 
	 * @param doctorId
	 * @return
	 */
	public ConnectionHandler getDoctor(UUID doctorId)
	{
		return mDoctors.get(doctorId);
	}

	/**
	 * Adds a connection of a doctor to the list of currently logged in doctors.
	 * 
	 * @param connectionHandler
	 */
	public void addDoctor(ConnectionHandler connectionHandler)
	{
		mDoctors.put(connectionHandler.getSenderId(), connectionHandler);
	}

	/**
	 * Returns a list of all paramedics currently logged in.
	 * 
	 * @return
	 */
	public List<ConnectionHandler> getParamedics()
	{
		return new ArrayList<ConnectionHandler>(mParamedics.values());
	}

	/**
	 * Returns the connection of the paramedic with the given ID or null it that
	 * paramedic is not currently logged in.
	 * 
	 * @param paramedicId
	 * @return
	 */
	public ConnectionHandler getParamedic(UUID paramedicId)
	{
		return mParamedics.get(paramedicId);
	}

	/**
	 * Adds a connection of a paramedic to the list of currently logged in
	 * paramedics.
	 * 
	 * @param connectionHandler
	 */
	public void addParamedic(ConnectionHandler connectionHandler)
	{
		mParamedics.put(connectionHandler.getSenderId(), connectionHandler);
	}

	/**
	 * Returns a map of all parts of current video transmissions that have not
	 * yet been completed.
	 * 
	 * @return
	 */
	public Map<UUID, List<byte[]>> getVideoPartsMap()
	{
		return mVideoPartsMap;
	}

	/**
	 * Returns a list of recent activity logs.
	 * 
	 * @return
	 */
	public List<ActivityLog> getActivityLogs()
	{
		return mActivityLogs;
	}

	/**
	 * Get activity log by video id.
	 * 
	 * @param videoId
	 * @return
	 */
	public ActivityLog getActivityLog(UUID videoId)
	{
		for (ActivityLog log : mActivityLogs)
		{
			if (log.getRecordingId().equals(videoId))
			{
				return log;
			}
		}

		return null;
	}

	/**
	 * Checks open connections to remove connections from the list of open
	 * connections that haven't been active for at least 40 seconds.
	 */
	private class ConnectionWatchDog extends Timer
	{
		@Override
		protected void onFinish()
		{
			sLogger.info("Checking open connections.");

			long now = new Date().getTime();
			int timeout = 40 * 1000;

			List<ConnectionHandler> connections = new ArrayList<ConnectionHandler>(mConnections);
			for (ConnectionHandler connection : connections)
			{
				// remove all connections from the list of open connections that
				// haven't been active for at least 40 seconds
				if (connection.getLatestActivity().getTime() + timeout <= now)
				{
					sLogger.info("Removing timed out connection from the list of open connections. (" + connection.getSenderId()
							+ ")");
					mConnections.remove(connection);
					connection.close();
				}
			}
		}
	}

	/**
	 * Cleans up the list of current activities. Removes activities that haven't
	 * been updated for at least 30 minutes.
	 */
	private class CleanUpTask extends Timer
	{
		@Override
		protected void onFinish()
		{
			sLogger.info("Clean up old stuff in the activity log and the video parts map.");

			long now = new Date().getTime();
			int thirtyMinutes = 30 * 60 * 1000;

			List<ActivityLog> activityLogs = new ArrayList<ActivityLog>(mActivityLogs);
			for (ActivityLog activity : activityLogs)
			{
				// remove all activity logs, that haven't been updated for at
				// least 30 minutes
				if (activity.getLatestActivity().getTime() + thirtyMinutes <= now)
				{
					sLogger.info("Removing activity log and remaining video parts (if existent) for video "
							+ activity.getRecordingId());
					mActivityLogs.remove(activity);
					mVideoPartsMap.remove(activity.getRecordingId());
				}
			}
		}
	}

	/**
	 * Countdown timer that waits for a given amount of milliseconds and then
	 * calls onFinish().
	 */
	private abstract class Timer
	{
		/** Thread for countdown timer */
		private Thread mThread;

		/**
		 * Starts countdown timer and calls onFinish() after the given amount of
		 * milliseconds.
		 * 
		 * @param countdown
		 * @param repeat
		 *            Repeats the whole process if repeat is true
		 */
		public void start(final int countdown, final boolean repeat)
		{
			mThread = new Thread()
			{
				@Override
				public void run()
				{
					try
					{
						while (repeat)
						{
							Thread.sleep(countdown);
							onFinish();
						}
					}
					catch (InterruptedException e)
					{
						; // nothing
					}
				}
			};

			mThread.start();
		}

		/**
		 * Allows us to stop the countdown timer.
		 */
		public void cancel()
		{
			mThread.interrupt();
		}

		/**
		 * onFinish() is called after the timer waited for the given amount of
		 * time.
		 */
		protected abstract void onFinish();
	}
}
